package it.unibo.refolding.pico.jpk;

import it.unibo.refolding.common.MyRuntimeException;
import it.unibo.refolding.pico.PlainDirectionData;
import it.unibo.refolding.pico.PlainPicoData;
import it.unibo.refolding.pico.transformers.ByteArray2ShortArrayStrategyFloat;
import it.unibo.refolding.pico.transformers.ByteArray2ShortArrayStrategyInt;
import it.unibo.refolding.pico.transformers.ByteArray2ShortArrayStrategyLong;
import it.unibo.refolding.pico.transformers.ByteArray2ShortArrayStrategyShort;
import it.unibo.refolding.pico.transformers.Transformer;
import it.unibo.refolding.pico.transformers.Transformers;

import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.ByteOrder;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Deque;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.builder.EqualsBuilder;

import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;

public class JpkDataExtractor {

  private static final Logger logger = Logger.getLogger(JpkDataExtractor.class.getSimpleName());

  /**
   * In Nanoscope, zLsbToMetresFactor = 3.051758E-4 * 1000 / 1e9 = 3.051758E-10,
   * so the maximum extension is zLsbToMetresFactor * 0xFFFF = 1.9999696053e-05.
   * Therefore Nanoscope basically scales things to 20 uM, so let's do the same
   * here. Note that nothing about z can be fixed from the command line, so we
   * compute every time the factor. Since here we don't know (for sure) which
   * one is the piezo sensitivity, we relate to metres rather than volts.
   */
  public static final double maxHeightM = 20e-6;

  public static List<PlainPicoData> parse(byte[] fileContent) {
    try {
      return parseThrowingIo(fileContent);
    } catch (IOException e) {
      throw new MyRuntimeException(e.toString(), e);
    }
  }

  public static List<PlainPicoData> parseThrowingIo(byte[] fileContent) throws IOException {
    Path tempFile = Files.createTempFile("jpk", ".tmp");
    try {
      Files.write(tempFile, fileContent);
      return parseThrowingIo(tempFile);
    } finally {
      Files.delete(tempFile);
    }
  }

  public static List<PlainPicoData> parse(Path path) {
    try {
      return parseThrowingIo(path);
    } catch (IOException e) {
      throw new MyRuntimeException(path + " " + e.getMessage(), e);
    } catch (Throwable t) {
      logger.log(Level.WARNING, "path={0} t={1}", new Object[] {path, t});
      throw t;
    }
  }

  private static List<PlainPicoData> parseThrowingIo(Path path) throws IOException {
    List<PlainPicoData> a = new ArrayList<>();
    URI uri = URI.create("jar:file:" + path.toUri().getPath());
    try (FileSystem fs = FileSystems.newFileSystem(uri, ImmutableMap.<String, Object>of())) {
      Path header = fs.getPath("/header.properties");
      Properties headerProperties = new Properties();
      try (InputStream inStream = Files.newInputStream(header)) {
        headerProperties.load(inStream);
      }
      String jpkDataFile = headerProperties.getProperty("jpk-data-file");
      if (!"spm-forcefile".equals(jpkDataFile)) {
        logger.log(Level.WARNING, "file={0} jpk-data-file={1} is not supported", new Object[] {
        path, jpkDataFile});
        return null;
      }
      String type = headerProperties.getProperty("type");
      if (!"force-scan-series".equals(type)) {
        logger.log(Level.WARNING, "file={0} type={1} is not supported", new Object[] {path, type});
        return null;
      }
      double xOffsetM = Double.parseDouble(headerProperties.getProperty("force-scan-series.header.position.x"));
      int noSegments = Integer.parseInt(headerProperties.getProperty("force-scan-series.header.force-settings.segments.size"));
      if (noSegments < 2) {
        logger.log(Level.WARNING, "Expected at least 2 segments but found {0}.", noSegments);
        return null;
      }
      Deque<ExtendPauseRetract> queue = new LinkedList<>();
      queue.push(new ExtendPauseRetract());
      /*
       * j skips zero-duration segments because they don't have a corresponding
       * subdirectory under segments.
       */
      int j = 0;
      boolean extendForceRamp = false;
      for (int i = 0; i < noSegments; ++i) {
        String styleKey = segmentPropertyKey(i, "style");
        String styleValue = headerProperties.getProperty(styleKey);
        String typeKey = segmentPropertyKey(i, "type");
        String typeValue = headerProperties.getProperty(typeKey);
        boolean extendForce = "z-extend-force".equals(typeValue);
        if ("extend".equals(styleValue) & ("z-extend-height".equals(typeValue) | extendForce)) {
          if (queue.peek().extendIndex[0] == -1) {
            if (extendForce) {
              logger.log(Level.FINE, "Approach of {0} is force-ramp.", path);
            }
            queue.peek().extendIndex[0] = i;
            queue.peek().extendIndex[1] = j;
            extendForceRamp = extendForce;
          } else {
            logger.log(
              Level.WARNING, "path={0} Multiple extend segments: {1} and {2}.", new Object[] {
              path, Arrays.toString(queue.peek().extendIndex), i});
          }
        } else if ("retract".equals(styleValue) & "z-retract-height".equals(typeValue)) {
          if (queue.peek().retractIndex[0] == -1) {
            queue.peek().retractIndex[0] = i;
            queue.peek().retractIndex[1] = j;
            queue.push(new ExtendPauseRetract());
          } else {
            logger.log(
              Level.WARNING, "path={0} Multiple retract segments: {1} and {2}.", new Object[] {
              path, Arrays.toString(queue.peek().retractIndex), i});
          }
        } else if ("pause".equals(styleValue)) {
          if (queue.peek().pauseIndex[0] == -1) {
            queue.peek().pauseIndex[0] = i;
            queue.peek().pauseIndex[1] = j;
          } else {
            logger.log(
              Level.WARNING, "path={0} Multiple pause segments: {1} and {2}.", new Object[] {
              path, Arrays.toString(queue.peek().pauseIndex), i});
          }
        }
        double duration = Double.parseDouble(headerProperties.getProperty(segmentPropertyKey(
          i, "duration")));
        if (duration != 0) {
          ++j;
        }
      }
      if (!queue.isEmpty()) {
        if (queue.peek().isEmpty()) {
          queue.pop();
        } else if (!queue.peek().isFinished()) {
          logger.log(
            Level.WARNING,
            "Need both approach and retraction, but found extendIndex={0} retractIndex={1}.",
            new Object[] {
            Arrays.toString(queue.peek().extendIndex), Arrays.toString(queue.peek().retractIndex)});
          queue.pop();
        }
      }
      logger.log(Level.FINE, "queue={0}", queue);
      checkSanity(queue);
      for (Iterator<ExtendPauseRetract> it = queue.descendingIterator(); it.hasNext();) {
        ExtendPauseRetract epr = it.next();
        double extendDuration = Double.parseDouble(headerProperties.getProperty(segmentPropertyKey(
          epr.extendIndex[0], "duration")));
        double retractDuration = Double.parseDouble(headerProperties.getProperty(segmentPropertyKey(
          epr.retractIndex[0], "duration")));
        double scanRateHz = 1 / (extendDuration + extendDuration);
        // int extendNumPoints =
        // Integer.parseInt(headerProperties.getProperty(segmentPropertyKey(extendIndex,
        // "num-points")));
        // int retractNumPoints =
        // Integer.parseInt(headerProperties.getProperty(segmentPropertyKey(retractIndex,
        // "num-points")));
        double extendZStartM = Double.parseDouble(headerProperties.getProperty(segmentPropertyKey(
          epr.extendIndex[0], "z-start")));
        double extendZEndM = Double.parseDouble(headerProperties.getProperty(segmentPropertyKey(
          epr.extendIndex[0], "z-end")));
        double retractZStartM = Double.parseDouble(headerProperties.getProperty(segmentPropertyKey(
          epr.retractIndex[0], "z-start")));
        double retractZEndM = Double.parseDouble(headerProperties.getProperty(segmentPropertyKey(
          epr.retractIndex[0], "z-end")));
        final double rampSizeM;
        double extendRampSizeM = extendZStartM - extendZEndM;
        double retractRampSizeM = retractZStartM - retractZEndM;
        if (extendRampSizeM == retractRampSizeM) {
          rampSizeM = extendRampSizeM;
          Preconditions.checkState(rampSizeM > 0, "rampSizeNm=%s", rampSizeM * 1e9);
        } else {
          rampSizeM = Double.NaN;
        }
        double pauseS = epr.pauseIndex[0] == -1 ? 0
          : Double.parseDouble(headerProperties.getProperty(segmentPropertyKey(
            epr.pauseIndex[0], "duration")));
        ;
        double extendVelocity = extendForceRamp ? Double.NaN : extendRampSizeM / extendDuration;
        double retractVelocity = retractRampSizeM / retractDuration;
        Segment extend = extractDirectionData(
          extendVelocity,
          fs.getPath(String.format(Locale.ROOT, "segments/%d", epr.extendIndex[1])), true);
        Segment retract = extractDirectionData(
          retractVelocity,
          fs.getPath(String.format(Locale.ROOT, "segments/%d", epr.retractIndex[1])), false);
        // If the settings are equal, we don't care about offsets.
        Preconditions.checkState(extend.settings.equals(retract.settings));
        SegmentSettings settings = extend.settings;
        /*
         * ZScanStart is probably related to
         * settings.heightNominalScalingOffset, but we don't really need it.
         * Also, we set sensZSensMPerV = 1 and put the actual ramp size in
         * metres, since sensZSensMPerV is only used with ZScanStart (which we
         * don't need) and ramp size.
         */
        double zScanStartHardValueV = 0;
        PlainPicoData plainPicoData = new PlainPicoData(
          xOffsetM, settings.forceScalingMultiplier, 1, zScanStartHardValueV,
          JpkDataExtractor.maxHeightM / 0xFFFF, Transformers.DEFLECTION_LIMIT_V,
          settings.distanceScalingMultiplier, scanRateHz, extend.data, retract.data, rampSizeM,
          pauseS);
        a.add(plainPicoData);
      }
    }
    return a;
  }

  private static void checkSanity(Iterable<ExtendPauseRetract> queue) {
    ExtendPauseRetract[] array = Iterables.toArray(queue, ExtendPauseRetract.class);
    for (int i = 0; i < array.length; ++i) {
      for (int j = i + 1; j < array.length; ++j) {
        Preconditions.checkArgument(array[i].isAfter(array[j]));
      }
    }
  }

  private static class SegmentSettings {

    private final double forceScalingMultiplier;
    @SuppressWarnings("unused")
    private final double forceScalingOffset;
    // Deflection Sensitivity
    private final double distanceScalingMultiplier;
    @SuppressWarnings("unused")
    private final double distanceScalingOffset;
    @SuppressWarnings("unused")
    private final double heightNominalScalingMultiplier;
    @SuppressWarnings("unused")
    private final double heightNominalScalingOffset;
    @SuppressWarnings("unused")
    private final double heightCalibratedScalingMultiplier;
    @SuppressWarnings("unused")
    private final double heightCalibratedScalingOffset;

    public SegmentSettings(double forceScalingMultiplier, double forceScalingOffset,
      double distanceScalingMultiplier, double distanceScalingOffset,
      double heightNominalScalingMultiplier, double heightNominalScalingOffset,
      double heightCalibratedScalingMultiplier, double heightCalibratedScalingOffset) {
      this.forceScalingMultiplier = forceScalingMultiplier;
      this.forceScalingOffset = forceScalingOffset;
      this.distanceScalingMultiplier = distanceScalingMultiplier;
      this.distanceScalingOffset = distanceScalingOffset;
      this.heightNominalScalingMultiplier = heightNominalScalingMultiplier;
      this.heightNominalScalingOffset = heightNominalScalingOffset;
      this.heightCalibratedScalingMultiplier = heightCalibratedScalingMultiplier;
      this.heightCalibratedScalingOffset = heightCalibratedScalingOffset;
    }

    @Override
    public boolean equals(Object o) {
      return EqualsBuilder.reflectionEquals(this, o);
    }
  }

  private static class Segment {
    private final SegmentSettings settings;
    private final PlainDirectionData data;

    public Segment(SegmentSettings settings, PlainDirectionData data) {
      this.settings = settings;
      this.data = data;
    }
  }

  private static Segment extractDirectionData(double velocity, Path segmentPath, boolean reverse)
    throws IOException {
    Path segmentHeader = segmentPath.resolve("segment-header.properties");
    Properties headerProperties = new Properties();
    try (InputStream inStream = Files.newInputStream(segmentHeader)) {
      headerProperties.load(inStream);
    }
    int numPoints = Integer.parseInt(headerProperties.getProperty("force-segment-header.num-points"));
    double forceScalingMultiplier = Double.parseDouble(headerProperties.getProperty("channel.vDeflection.conversion-set.conversion.force.scaling.multiplier"));
    double forceScalingOffset = Double.parseDouble(headerProperties.getProperty("channel.vDeflection.conversion-set.conversion.force.scaling.offset"));
    /*
     * Usually the offset is zero, but we only care that it's the same in both
     * approach and retraction.
     */
    double distanceScalingMultiplier = Double.parseDouble(headerProperties.getProperty("channel.vDeflection.conversion-set.conversion.distance.scaling.multiplier"));
    double distanceScalingOffset = Double.parseDouble(headerProperties.getProperty("channel.vDeflection.conversion-set.conversion.distance.scaling.offset"));

    double heightNominalScalingMultiplier = Double.parseDouble(headerProperties.getProperty("channel.height.conversion-set.conversion.nominal.scaling.multiplier"));
    double heightNominalScalingOffset = Double.parseDouble(headerProperties.getProperty("channel.height.conversion-set.conversion.nominal.scaling.offset"));

    double heightCalibratedScalingMultiplier = Double.parseDouble(headerProperties.getProperty("channel.height.conversion-set.conversion.calibrated.scaling.multiplier"));
    double heightCalibratedScalingOffset = Double.parseDouble(headerProperties.getProperty("channel.height.conversion-set.conversion.calibrated.scaling.offset"));

    /*
     * The height scaling offset doesn't affect us as long as it's the same in
     * both approach and retraction, and the nominal and calibrated multipliers
     * are also the same.
     */
    // Preconditions.checkArgument(heightScalingOffset == 0);

    String zBase = String.format(Locale.ROOT, "channel.%s.data", "height");
    Transformer heightStrategy = readStrategy(headerProperties, zBase);
    Path heightPath = segmentPath.resolve(headerProperties.getProperty(zBase + ".file.name"));
    double heightFactor = hightFactor(
      heightNominalScalingMultiplier, heightCalibratedScalingMultiplier);
    short[] z = heightStrategy.transform(Files.readAllBytes(heightPath), heightFactor);
    Preconditions.checkState(z.length == numPoints);

    String deflectionBase = String.format(Locale.ROOT, "channel.%s.data", "vDeflection");
    Transformer deflectionStrategy = readStrategy(headerProperties, deflectionBase);
    Path deflectionPath = segmentPath.resolve(headerProperties.getProperty(deflectionBase
      + ".file.name"));
    short[] deflection = deflectionStrategy.transform(
      Files.readAllBytes(deflectionPath), Transformers.voltLsb(Transformers.DEFLECTION_LIMIT_V));
    Preconditions.checkState(deflection.length == numPoints);

    if (reverse) {
      ArrayUtils.reverse(z);
      ArrayUtils.reverse(deflection);
    }

    SegmentSettings settings = new SegmentSettings(
      forceScalingMultiplier, forceScalingOffset, distanceScalingMultiplier, distanceScalingOffset,
      heightNominalScalingMultiplier, heightNominalScalingOffset,
      heightCalibratedScalingMultiplier, heightCalibratedScalingOffset);
    PlainDirectionData data = new PlainDirectionData(deflection, z, velocity);
    return new Segment(settings, data);
  }

  public static double hightFactor(
    double heightNominalScalingMultiplier, double heightCalibratedScalingMultiplier) {
    /*
     * heightNominalScalingMultiplier is negative, and we negate it to have the
     * same convention as with nanoscope.
     */
    return JpkDataExtractor.maxHeightM / 0xFFFF / (-heightNominalScalingMultiplier)
      / heightCalibratedScalingMultiplier;
  }

  public static Transformer readStrategy(Properties headerProperties, String base) {
    String dataType = headerProperties.getProperty(base + ".type");
    if ("float-data".equals(dataType) || "float".equals(dataType)) {
      return new ByteArray2ShortArrayStrategyFloat(ByteOrder.BIG_ENDIAN);
    } else {
      String encoderType = headerProperties.getProperty(base + ".encoder.type");
      String encoderScalingType = headerProperties.getProperty(base + ".encoder.scaling.type");
      if (!"linear".equals(encoderScalingType)) {
        logger.log(Level.WARNING, "Unknown encoder scaling type: {0}.", encoderScalingType);
        return null;
      }
      double encoderScalingOffset = Double.parseDouble(headerProperties.getProperty(base
        + ".encoder.scaling.offset"));
      double encoderScalingMultiplier = Double.parseDouble(headerProperties.getProperty(base
        + ".encoder.scaling.multiplier"));
      if ("signedlong".equals(encoderType) | "signedlong-limited".equals(encoderType)) {
        return new ByteArray2ShortArrayStrategyLong(
          ByteOrder.BIG_ENDIAN, encoderScalingOffset, encoderScalingMultiplier, true);
      } else if ("integer-data".equals(dataType) || "memory-integer-data".equals(dataType)) {
        if ("signedinteger".equals(encoderType) || "signedinteger-limited".equals(encoderType)) {
          return new ByteArray2ShortArrayStrategyInt(
            ByteOrder.BIG_ENDIAN, encoderScalingOffset, encoderScalingMultiplier, true);
        } else if ("unsignedinteger".equals(encoderType)
          || "unsignedinteger-limited".equals(encoderType)) {
          return new ByteArray2ShortArrayStrategyInt(
            ByteOrder.BIG_ENDIAN, encoderScalingOffset, encoderScalingMultiplier, false);
        } else {
          throw new IllegalArgumentException(String.format(
            Locale.ROOT, "Unexpected combination dataType=%s encoderType=%s.", dataType,
            encoderType));
        }
      } else if ("short-data".equals(dataType) || "short".equals(dataType)
        || "memory-short-data".equals(dataType)) {
        if ("signedshort".equals(encoderType) || "signedshort-limited".equals(encoderType)) {
          return new ByteArray2ShortArrayStrategyShort(
            ByteOrder.BIG_ENDIAN, encoderScalingOffset, encoderScalingMultiplier, true);
        } else if ("unsignedshort".equals(encoderType)
          || "unsignedshort-limited".equals(encoderType)) {
          return new ByteArray2ShortArrayStrategyShort(
            ByteOrder.BIG_ENDIAN, encoderScalingOffset, encoderScalingMultiplier, false);
        } else {
          throw new IllegalArgumentException(String.format(
            Locale.ROOT, "Unexpected combination dataType=%s encoderType=%s.", dataType,
            encoderType));
        }
      } else {
        throw new IllegalArgumentException(String.format(
          Locale.ROOT, "Unexpected data type: %s.", dataType));
      }
    }
  }

  private static String segmentPropertyKey(int i, String t) {
    return String.format(Locale.ROOT, "force-scan-series.header.force-settings.segment.%d.%s", i, t);
  }
}
